feat(mcp): add manage_adr mode='append' - #1243
Conversation
'update' replaces the whole ADR, so adding a single entry costs a full re-send of the document. For a large ADR that is both expensive and a chance to silently drop existing text on the round-trip: the caller has to reproduce every byte it did not intend to change. 'append' concatenates server-side, so the stored copy is the only source of the prefix. Trailing newlines are trimmed and exactly one blank line is inserted, so repeated appends do not accumulate whitespace; an empty or missing ADR degrades to a plain create. append without 'content' fails with status='missing_content' instead of falling through to the 'get' branch — a caller that meant to write must not receive a success-shaped read. The response carries content_length and appended_length so callers can confirm the write landed without re-fetching the document, which for large ADRs is the expensive part. Tests: append extends without replacing (order + single-blank-line separator + sections still parse), append creates when absent, append without content errors and leaves the ADR untouched, and the mode is advertised in tools/list. Signed-off-by: andis777 <24672074+andis777@users.noreply.github.com>
CI is green — this closes the verification gap flagged in the descriptionThe PR description says compilation was unverified because no C toolchain was available where this was written. CI has now covered exactly that, so please disregard that caveat: all 25 checks pass. Builds and full suites on every target: The four new tests actually executed"Suite passed" is not the same claim as "my tests ran", so I checked. Per-test names are not in the CI logs — a control grep for the pre-existing
One note on the audit, in case it comes up in review: |
|
Thanks for the careful tests and docs. This needs an ADR-model decision before implementation approval. The current |
|
Thanks for this, and sorry for the wait. Queued for review. MERGEABLE with all 28 checks green, +205/-3 across three files. An append mode for |
|
Reviewed in full. The implementation is clean and the security question came back clean too — what is holding this is a surface decision that belongs to the maintainer, not a problem with your work. The security question I most wanted answered, answered. Because this mode writes content at an LLM's request, I went looking for a path-traversal surface. There isn't one: the append path performs no filesystem write at all. Storage is the SQLite Things you got right that are worth naming:
Why it is not merged yet. Adding a mode to One alternative the maintainer will weigh, which you could not have known about: the store layer already contains an unexposed Three things worth fixing regardless of which way that lands:
I will come back to you with the direction answer. Thank you for the care on this one; the review was quick because the work was tidy. |
|
The direction answer I promised on 31 July, a month late — I am sorry it took this long, and doubly so because you were told to hold off and did. The decision: the ADR write surface grows a section-level update mode, built on You identified the true problem: with The section shape also happens to be cap-enforced already ( The offer: this lands as your contribution if you want it. The store layer is built and tested; what remains is exposing it through
If the month has moved you on, say the word and I will implement it with |
|
@andis777 — taking the second half of my own offer rather than leaving you waiting: I'm implementing the section-level mode in-house, with To be straight about why, since I offered you first refusal barely a day ago and am not waiting for the answer: the queue is moving now, the branch has drifted hard (80 commits have touched Your work loses on exactly one property, and it isn't quality. Append is non-idempotent on client retry — a lost response means a silently duplicated chunk. The sectioned shape is retry-safe by construction and already cap-enforced. Everything else in your PR held up under review: the implementation was clean, the string handling correct and bounds-checked, no filesystem exposure at all, and your scope discipline was exemplary — you declined to fix the analogous Your CI comment was the best evidence in this entire queue. Proving your four tests actually executed via per-shard pass-count deltas (1854 → 1858, other shards unchanged), and then explicitly noting that a control grep for an existing test name also returns nothing so log-absence proves nothing either way — that is precisely the right discipline. Three separate agents have been caught out by "wrong-suite false greens" in this repo in the last day; you pre-empted it unprompted on your first contribution. Two defects review turned up that neither of us had named, recorded here because they'd have bitten whoever implemented this:
Both are carried into the new implementation, along with your empty-content and degrade-to-create guards, and a Thank you for the patience and for a genuinely strong first contribution. |
|
Landed. @andis777 — thank you, and a straight account of what happened to your work. Your implementation was clean; it lost on one property. Whole-document Your CI comment was the best evidence in this queue. Proving your four tests actually executed via per-shard pass-count deltas (1854 → 1858, other shards unchanged), then noting that a control grep for an existing test name also returns nothing so log-absence proves nothing either way — that is exactly the right discipline. Several agents working this repo have been caught by "wrong-suite false greens" in the last two days; you pre-empted it unprompted on a first contribution. Two defects your review turned up that nobody had named, both now fixed and both of which would have bitten whoever implemented this:
And building it exposed something worse, which is the real story. The first implementation merged by parse → apply → re-render, and Which means custom headings now work — A Thank you for a genuinely strong first contribution. I would welcome your next. |
manage_adr could only replace: mode='update' overwrites the stored document in full, so adding one entry costs a re-send of the whole ADR. That is a data-integrity problem before it is a cost one — the caller has to reproduce every byte it did not intend to change, so the unchanged prefix survives only as well as the round-trip that carried it. mode='set_sections' rewrites only the sections named in `section_updates` and leaves the rest of the stored document untouched, so the stored copy stays the authority for everything the caller did not name. Unlike a whole-document append it is idempotent: a client that loses a response and retries re-sets the same section to the same body and the document is byte-identical, where an append would silently duplicate the chunk. Only the six canonical section names are writable. That is a correctness constraint rather than a style rule: adr_try_section_header() parses ONLY canonical headers, so a non-canonical '## FOO' written here would be read back as body text of the section above it, and a second identical write would append a duplicate — destroying the idempotence the mode exists for. Three things the new mode needed that were not there: - cbm_store_adr_update_sections() now wraps its read-modify-write in BEGIN IMMEDIATE. Three writers replace this row wholesale — the indexing pipeline, the UI POST /api/adr handler, and mode='update' — so the unguarded get/merge/store lost whichever of them committed between the read and the UPSERT. mode='update' is a single atomic UPSERT and never had that window; a section merge introduces it. - set_sections joins the write_request classification. A mode missing from it takes no per-project mutation lease, resolves the store query-only, and never reaches open_adr_store_for_write — its write would be attempted through a read-only handle while an index runs. - The write path reads the legacy <root>/.codebase-memory/adr.md itself. The existing migration runs on the read path only because it must not block on the lease; without this a section write would merge onto an empty document and discard an ADR still present on disk. CBM_ADR_MAX_LENGTH now applies to an MCP write path: it is enforced inside cbm_store_adr_update_sections, which mode='update' does not go through. An empty section body, an unknown section name and a missing section_updates are all rejected before any store is opened, so a caller that meant to write never receives a success-shaped read. Section-level update was chosen over the whole-document append proposed in PR DeusData#1243; the analysis that established the problem is from that PR. Co-authored-by: andis777 <24672074+andis777@users.noreply.github.com> Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Problem
manage_adrcan only replace:mode='update'overwrites the stored document in full. Adding a single entry to a long-lived ADR therefore costs a full re-send of the whole document.That is more than an efficiency problem. The caller has to reproduce every byte it did not intend to change, so the prefix is only as safe as the round-trip that carried it — an agent re-emitting ~60 KB of prose to append one paragraph has ~60 KB of opportunity to silently drop or mangle text that nobody asked it to touch. Verifying the result means fetching the document back and diffing it, which doubles the cost again.
Change
Adds
mode='append', which concatenates server-side so the stored copy is the only source of the prefix:appendis safe as a first write.appendwithoutcontentreturnsstatus='missing_content'andisErrorinstead of falling through to thegetbranch — a caller that meant to write must not receive a success-shaped read. (updatehas the same fall-through today; left alone to keep this change scoped.)content_lengthandappended_lengthso callers can confirm the write landed without re-fetching the document.get,update,storeandsectionsare untouched.The
modeenum andcontentnow carry descriptions, mostly soupdate = REPLACE the whole documentis visible at the call site rather than something you learn by overwriting an ADR.Tests
Four cases in
tests/test_mcp.c:tool_manage_adr_append_extends_without_replacing— prefix survives verbatim, new chunk lands after it, exactly one blank line joins them, both sections still parse viamode='sections'.tool_manage_adr_append_creates_when_absent— exact-match assert that no leading blank line is introduced.tool_manage_adr_append_without_content_errors— errors and leaves the stored ADR byte-identical.tool_manage_adr_append_is_advertised— the mode appears intools/list, so callers can discover it instead of continuing to pay for rewrites.Verification I could not do
I was unable to build or run the test suite — no C toolchain was available on the machine this was written on. Please treat compilation as unverified; I would rather flag this than have it discovered in review.
What I did instead:
heap_strdup,SKIP_ONEfromsrc/foundation/constants.h,yyjson_mut_obj_add_uintas used insrc/pipeline/artifact.c,cbm_mcp_handle_tool,ASSERT_STR_EQ), and mirrored the existingsize_t/SKIP_ONEindexing idiom fromadr_list_sections_from_contentto stay within the project's-Werrorsettings.adr_append_contentto a scratch script and ran the boundary cases: CRLF endings, multiple trailing newlines, empty existing content, content consisting only of newlines, empty addition, and four appends in a row (no triple newlines accumulate). All behaved as asserted in the C tests.scripts/security-audit.sh: passes. It reportssrc/mcp/mcp.c has 17 file read operations (expected max 15), but that is pre-existing —git show HEAD~1:src/mcp/mcp.c | grep -c 'fopen\|fread\|read_file'is also 17. This change adds no file reads.Happy to adjust naming (
appendvsadd), the separator policy, or themissing_contentstatus if you'd prefer different conventions.🤖 Generated with Claude Code